Aby lepiej zrozumieć TDD, przejdziemy do praktycznego przykładu. W tym celu rozwiniemy aplikację agencji turystycznej, nad którą pracowaliśmy w poprzednich modułach.
Funkcjonalnością, którą będziemy starać się zaimplementować w tym module, będzie komponent odliczający czas do promocji "Happy Hour", która jest aktywna codziennie od 12:00 do 13:00.
Będzie to wyglądało następująco:
Pokażemy Ci, jak zabrać się za przeanalizowanie tej funkcjonalności, a następnie opiszemy testy do każdego z wymagań. Na końcu tego submodułu Twoim zadaniem będzie implementacja tego komponentu w taki sposób, aby spełniła wymagania.
Zanim zaczniemy, warto wspomnieć o okolicznościach tego zlecenia. Możemy założyć, że klient chciałby zobaczyć, jak taki komponent by działał i skonsultować to ze swoimi współpracownikami. Na tym etapie nie tworzymy docelowego rozwiązania, które będzie funkcjonowało na stronie, tylko tzw. proof of concept. Chcemy jednak od razu pisać kod w taki sposób, aby jak największą jego część można było później wykorzystać w docelowym rozwiązaniu.
Założenia
Aby napisać testy, nie wiedząc, jak będzie dokładnie wyglądała końcowa implementacja, musimy mieć przynajmniej ogólne pojęcie, jak dana funkcjonalność ma działać. Zaczniemy więc od spokojnego przeanalizowania założeń – dzięki temu będziemy mogli z grubsza określić wymagania. To z kolei pozwoli nam dobrze przygotować testy, które wezmą pod uwagę odpowiednie przypadki.
Zadaniem komponentu HappyHourAd będzie wyświetlenie nagłówka "Happy Hour" oraz czasu do następnej promocji. Na razie będziemy wyświetlać czas w sekundach, ale może w przyszłości zmienimy sposób wyświetlania. ;)
Jak już wspomnieliśmy, promocja odbywa się codziennie o 12:00 (w południe), więc musimy wyświetlać czas pozostały do tej godziny. Wyjątkiem będzie czas trwania promocji – w godzinach od 12:00 do 13:00 zamiast odliczania, komponent powinien pokazywać komunikat "It's your time! Take advantage of Happy Hour! All offers 20% off!". Warto pamiętać, że ten komunikat ma wyświetlać się zarówno kiedy strona zostanie otworzona między 12:00, a 13:00, jak i w sytuacji, gdy strona była otwarta wcześniej i odliczanie doszło do zera.
Uwaga! "Happy Hour" ma się odbywać o 12:00 czasu UTC, a nie lokalnego. W końcu różni klienci mogą pochodzić z różnych stref czasowych. Lepiej więc bazować na czasie, który obowiązuje w siedzibie firmy klienta.
Na razie to koniec wymagań – piszemy tylko proof of concept, więc nie musimy się przejmować niektórymi aspektami jego działania:
- jeśli strona jest otwarta w momencie, gdy wybije godzina 13:00, nie musimy ukrywać komunikatu o promocji i pokazywać licznika,
- ceny usług nie ulegną zmianie, ponieważ nasz komponent nie jest za nie odpowiedzialny,
- czas ma być wyświetlany w sekundach, mimo że będzie to mało czytelne,
- treść nagłówka i informacji o promocji będą przekazane komponentowi jako wartości propsów (wpisane na sztywno w komponencie nadrzędnym),
- godzina startu i zakończenia promocji może być na razie wpisana na sztywno w kodzie komponentu.
Tworzyliśmy już bardziej skomplikowane funkcjonalności, więc powinniśmy sobie z tym poradzić!
Jak będzie wyglądał ten komponent?
Chcemy napisać precyzyjne testy, więc warto zastanowić się, jak ten komponent powinien być zbudowany. Nie chodzi tutaj o to, jak dokładnie zostanie zaimplementowany, ani jak będą działać poszczególne metody – chodzi o oczekiwane efekty. Pamiętaj bowiem, że zadaniem testów jest sprawdzanie, czy końcowy efekt jest zgodny z planem, a nie tylko samego sposobu implementacji.
Analizując wcześniej zapisane założenia, możemy zaplanować taki komponent w miarę łatwo. Nazwiemy go HappyHourAd, a jego zadaniem będzie wyświetlanie nagłówka "Happy Hour" i komunikatu, który będzie wyświetlał odliczanie albo informację o tym, że aktualnie trwa promocja. Najlepiej będzie nie zakładać, że np. nagłówek to będzie <h3> – zamiast tego posłużymy się nazwami klas, co będzie bardziej elastycznym podejściem.
Co musimy przetestować?
Po przeanalizowaniu założeń oraz zaplanowaniu wyglądu komponentu możemy już dość szybko dojść do tego, co właściwie trzeba będzie przetestować i jakiego zachowania mamy oczekiwać.
Zwróć uwagę, że nie napisaliśmy jeszcze ani jednej linii kodu, a mimo tego możemy już wyobrazić sobie, jakie testy będą nam potrzebne. Nie jest to dużo trudniejsze niż w podejściu "najpierw funkcjonalność, potem testy" – wtedy mieliśmy gotowy kod, na którym mogliśmy się oprzeć, ale tu mamy za to solidne wyobrażenie, jak będzie on wyglądać.
Warto na tym etapie wypisać sobie, co dokładnie będziemy chcieli przetestować. Testy powinny sprawdzać:
- czy komponent w ogóle się renderuje i nie powoduje błędu,
- czy renderowane są oba elementy (nagłówek i komunikat),
- czy nagłówek ma treść przekazaną komponentowi w propsie
title,
- czy w momencie wyświetlenia strony poza godzinami promocji, komunikat wyświetla odliczanie z odpowiednią liczbą sekund,
- czy odliczanie co sekundę zmniejsza wyświetlaną liczbę,
- czy w momencie wyświetlenia strony w godzinach promocji, komunikat wyświetla informację o promocji, którego treść znajduje się w propsie
promoDescription,
- czy jeśli strona została otwarta przed startem promocji, a następnie odliczanie dotarło do zera, to czy zostanie wyświetlony informację o promocji.
I to prawdopodobnie tyle. Czy to za mało? A może za dużo...?
Ile testów jest potrzebne?
Przyjęło się mówić, że liczba testów jest wystarczająca, jeśli nie potrafisz wyobrazić sobie już więcej scenariuszy błędów. Jeśli boisz się, że liczba testów jest za mała, to postaraj zastanowić się, jak moglibyśmy popsuć ten komponent – np. przekazując mu jakieś dziwne wartości propsów.
Na przykład, nasz komponent otrzyma propsy z godzinami startu i końca promocji. Zapewne w przyszłości redaktor strony będzie mógł w jakiś sposób (np. w CMS-ie) ustawić te godziny. Co będzie, jeśli wpisze, że promocja ma zaczynać się o godzinie 25:73 (siedemdziesiąt trzy minuty po godzinie dwudziestej piątej)?
W tym konkretnym przypadku założymy, że godzina będzie zawsze podawana w poprawnym formacie 24-godzinnym, ponieważ komponent nadrzędny będzie docelowo dbał o poprawność wartości propsów. Spróbuj jednak pomyśleć, w jaki inny sposób moglibyśmy zepsuć ten komponent bez ingerowania w jego kod.
Takie dziwne scenariusze nazywa się warunkami brzegowymi, czyli edge cases. Najczęściej opierają się one o nieprzewidziane akcje człowieka albo rzadko występujące sytuacje – przykładem może być sekunda przestępna, która w naszym przypadku nie ma znaczenia, ale w systemach kontroli lotów może spowodować spore problemy.
Jeśli mimo starań nie potrafisz znaleźć takiego scenariusza, w którym komponent mógłby błędnie działać – możesz uznać, że masz wystarczająco dużo testów. :)
Dużą zaletą TDD jest to, że piszemy testy do bardzo małych fragmentów kodu, więc naprawdę łatwo i szybko możemy ustalić wszystkie możliwe scenariusze. W przypadku podejścia nie-TDD, gdzie często mamy do przetestowania multum kodu, o wiele łatwiej o ominięcie jakiegoś przypadku.
Piszemy testy
Przejdźmy do rzeczy. Jak już wiesz, testy jednostkowe możemy trzymać w osobnym folderze __tests__, lub zaraz przy samym testowanym komponencie/funkcji. Będziemy stosować to drugie podejście, tak samo, jak robiliśmy to już w poprzednim module.
Otwórz projekt agencji turystycznej z poprzedniego modułu i w katalogu features stwórz folder na nasz nowy komponent. Nazwij go zgodnie z naszą konwencją, a więc - HappyHourAd. W tym katalogu utwórz trzy pliki: HappyHourAd.js, HappyHourAd.scss i HappyHourAd.test.js. Stosujemy TDD, czyli najpierw zajmiemy się ostatnim z tych plików.
Czy napiszemy najpierw wszystkie testy, aby dopiero potem zabrać się za całą implementację? Zdecydowanie nie! To byłoby podejście "tests first", a nie TDD. Jak mówiliśmy już wcześniej, W TDD staramy się wykonywać testy na bieżąco.
W związku z tym proces będzie następujący:
- piszemy pierwszy test, który z założenia nie przechodzi, bo dotyczy funkcjonalności, która jeszcze nie została zaimplementowana
- implementujemy funkcjonalność sprawdzaną przez pierwszy test, aby ten test przechodził pozytywnie,
- zastanawiamy się, czy kod jest dobrze napisany – czyli czy nie wymaga drobnego refactoringu, aby np. był bardziej logicznie zorganizowany i prostszy do zrozumienia dla innego developera,
- piszemy drugi test, który z założenia nie przechodzi...
- implementujemy funkcjonalność sprawdzaną przez drugi test...
- itd.
Otwórz teraz plik HappyHourAd.test.js – zaczynamy pisanie testów!
Test 1: czy komponent się renderuje
Zaczniemy od najprostszego testu – renderowania komponentu. Ten test ma za zadanie sprawdzać, czy komponent w ogóle się renderuje i nie powoduje błędu. Następnie zaimplementujemy kod w pliku komponentu, który faktycznie się tym zajmie.
Oczywiście trzymamy się podejścia – najpierw test, potem implementacja.
Zacznij od napisania testu. Na razie nie ma tu nowości, więc spróbuj napisać ten test samodzielnie, wzorując się na przykładach z poprzedniego modułu. Następnie, klikając w poniższy guzik, możesz sprawdzić, czy Twój test jest napisany poprawnie.
Pokaż kod testu
Ukryj kod testu
import React from 'react';
import { shallow } from 'enzyme';
import HappyHourAd from './HappyHourAd';
describe('Component HappyHourAd', () => {
it('should render without crashing', () => {
const component = shallow(<HappyHourAd />);
expect(component).toBeTruthy();
});
});
Uwaga!
W poprzednim module w przypadku testowania komponentu (np. Hero) czasami używaliśmy shallow pojedynczo dla każdego przypadku testowego (it). Miało to sens, bowiem testowaliśmy komponent z różnymi parametrami, więc to wywołanie za każdym razem było inne. Tutaj sytuacja wydaje się odmienna. Zawsze potrzebujemy tego samego komponentu, sprawdzamy tylko różne jego aspekty.
Czy nie wystarczyłoby więc przygotować tego raz? Na samej górze? Albo użyć do tego funkcji beforeEach?
const component = shallow(<HappyHourAd />);
Bezpieczniej robić to jednak w każdym teście z osobna. Po pierwsze, nawet jeśli w tej chwili wywołujemy go tak samo, to może się to w przyszłości zmienić. Po drugie, możliwe, że w niektórych case'ach będziemy chcieli zmienić coś w otoczeniu, w którym ten komponent wywołamy. Będziemy to robić, np. przy testowaniu liczby sekund wyświetlanej w komunikacie. Wtedy przed wywołaniem komponentu będziemy chcieli zasymulować jakąś konkretną godzinę – np. że w momencie wyświetlenia strony jest godzina 11:00 czasu UTC.
Zgodnie z założeniami TDD, na ten moment nasz test nie może przejść. Ma to sens, skoro nie napisaliśmy jeszcze odpowiedniej implementacji komponentu.
Uruchom więc teraz task test:watch i zobacz, czy konsola faktycznie poinformuje nas o teście, który nie przeszedł.
Liczba testów wymieniona w podsumowaniu może być u Ciebie inna niż na powyższym screenie. Ważne jest tylko to, że powinien być tylko jeden test ze statusem failed – test, który przed chwilą napisaliśmy. Jeśli tak jest, to dobrze!
Teraz czas na odpowiednią modyfikację naszego komponentu, aby test został zaliczony. To Twoje zadanie, ale nie będzie to nic trudnego. Dodaj do pliku HappyHourAd.js nowy, pusty reactowy komponent klasowy. Metoda render może być praktycznie pusta. Napisz tylko tyle kodu, ile jest wymagane, aby test przeszedł.
Jeśli wszystko zostało wykonane przez Ciebie poprawnie, to Jest sam powinien zauważyć zmiany, ponieważ włączyliśmy go w trybie --watchAll (za pomocą npm run test:watch). W terminalu powinna pojawić się informacja, że wszystkie testy przechodzą pozytywnie.
Teraz pozostaje nam ewentualny refactoring napisanego kodu. Jeśli uważasz, że mimo przejścia testu, dobrze byłoby jakoś zmodyfikować Twój aktualny kod – najlepiej zrobić to właśnie teraz. Pamiętaj tylko, że musisz go zmieniać tak, aby test wciąż przechodził.
Na razie, jednak wstawiliśmy tylko pusty kod komponentu, więc raczej nie mamy niczego do refactorowania. W takim razie zakończyliśmy pierwszy cykl pisania naszego nowego komponentu w podejściu TDD!
Red, Green, Refactor
Cały proces TDD często określa się mianem Red, Green, Refactor. To trochę uproszczenie, ale dobrze oddaje istotę tej metody.
- Najpierw piszemy test, który nie przechodzi – faza nazywana Red, ponieważ Jest pokazuje oblany test na czerwono.
- Potem piszemy kod, który powinien spowodować, że test przejdzie – to faza Green, ponieważ test będzie w kolorze zielonym.
- Na końcu wprowadzamy ew. poprawki w kodzie – faza Refactor.
Jeśli masz problem z zapamiętaniem cyklu TDD, to najłatwiej przyswoić go sobie właśnie jako ten skrót – Red, Green, Refactor.
Test 2: czy renderowane są oba elementy
Kolejny case, w którym przygotowanie testu nie będzie dla Ciebie nowością. Również implementacja kodu w komponencie będzie bardzo prosta. Załóżmy, że nagłówek (np. <h3>) będzie miał klasę title, a komentarz z sekundami może być np. divem z klasą countdown. Musimy sprawdzić, czy oba te elementy istnieją w komponencie. Pamiętaj, że render może zwracać tylko jeden element, więc umieścimy nagłówek i komentarz w divie. Oczywiście, wciąż trzymamy się cyklu Red, Green, Refactor.
Zacznij od napisania testu. W poprzednim module pokazywaliśmy już, jak testować istnienie elementów w komponencie. Spróbuj więc napisać test bez naszej pomocy, a potem porównaj z rozwiązaniem dostępnym pod poniższym guzikiem.
Zwróć uwagę, że będziemy wielokrotnie wykorzystywać selektory '.title' i '.promoDescription'. Aby uniknąć ewentualnych literówek, najlepiej będzie zapisać je na początku pliku w obiekcie select. W testach nie będziemy wpisywać tych selektorów, tylko używać wartości z obiektu select.
Pokaż kod testu
Ukryj kod testu
const select = {
title: '.title',
promoDescription: '.promoDescription',
};
it('should render heading and description', () => {
const component = shallow(<HappyHourAd />);
expect(component.exists(select.title)).toEqual(true);
expect(component.exists(select.promoDescription)).toEqual(true);
});
W tej chwili test nie powinien jeszcze oczywiście przechodzić (faza Red). Następnie czas na implementację kodu w komponencie. To będzie bardzo proste zadanie: wystarczy dodać w funkcji render oba elementy. Gdy to zrobisz, Jest powinien wskazać, że test zaczyna przechodzić pozytywnie (faza Green).
Na końcu możesz jeszcze poprawić swój kod (faza Refactor). Prawdopodobnie nie będzie to jednak w tym przypadku konieczne.
Test 3: czy nagłówek ma treść z propsa
Treść, której oczekujemy w nagłówku, to wartość propsa title. Twoim zadaniem jest napisanie odpowiedniego testu, a następnie taka modyfikacja komponentu, aby ten test spełniał.
Sam test będzie stosunkowo prosty do napisania. Bardzo podobny znajdziesz również w jednym z przykładów z poprzedniego modułu. Spróbuj napisać go bez naszej pomocy.
Podobnie jak zrobiliśmy to z selektorami w obiekcie select, warto na początku pliku zapisać w stałej mockProps obiekt zawierający testowe propsy naszego komponentu (czyli właściwości title i promoDescription). W tym i wszystkich kolejnych testach będziemy je przekazywać do renderowanego komponentu, czyli użyjemy:
const component = shallow(<HappyHourAd {...mockProps} />);
Pamiętaj, aby trzymać się zasady Red, Green, Refactor – zacznij od napisania testu, następnie zaimplementuj funkcjonalność, a na końcu zastanów się, czy chcesz jakoś poprawić napisany kod.
Test 4: czy komunikat wyświetla odpowiednią liczbą sekund
Ten przypadek będzie trochę trudniejszy. Pozwoli nam jednak nieco lepiej poznać obsługę dat w JavaScripcie.
Musimy sprawdzić, czy komponent potrafi ustalić na samym początku, ile czasu pozostało do rozpoczęcia promocji. Jednak ten test może być przeprowadzony tylko wtedy, kiedy promocja nie jest aktywna. Inaczej test nie będzie miał sensu, ponieważ zamiast liczby sekund pokazywana byłaby informacja o promocji.
Co więcej, nasz test musi znać poprawną odpowiedź, czyli poprawną liczbę, która powinna być wyświetlana! I co mamy teraz zrobić? Przecież test może być wykonywany o różnych godzinach i za każdym razem poprawna liczba byłaby inna!
Na szczęście możemy dość łatwo poradzić sobie z tym problemem! Możemy podmienić funkcję, której nasz komponent użyje do sprawdzenia aktualnej godziny. Dzięki temu komponent będzie myślał, że jest zawsze ta sama godzina – nie zważając kompletnie na stan faktyczny!
Zacznijmy od dodania na końcu pliku HappyHourAd.test.js nowej sekcji describe:
describe('Component HappyHourAd with mocked Date', () => {
});
Jak widzisz z opisu, ten zestaw testów będzie przeprowadzany dla godziny 11:57:58. Zanim przejdziemy do pisania samego testu, musimy zająć się zasymulowaniem aktualnej godziny. Aby to zrobić, musimy przewidzieć, jak zadziała komponent.
W JavaScripcie najczęstsze sposoby poznania aktualnej godziny to:
const currentTime = new Date(); – wywołanie konstruktora klasy Date bez żadnych argumentów zwróci instancję tej klasy dla bieżącego momentu,
const currentTime = Date.now(); – jeśli nie potrzebujemy instancji klasy Date, tylko tzw. timestamp (wyjaśnienie poniżej), możemy użyć metody Date.now(),
const currentTime = new Date(Date.now()); – niektórzy developerzy nie wiedzą o tym, że można wywołać konstruktor Date bez argumentu, więc podają jako argument aktualny timestamp pobrany z Date.now().
Timestamp
Formalnie, timestamp oznacza dowolny znacznik czasu, a w dosłownym tłumaczeniu oznacza pieczątkę z aktualną godziną. Wśród programistów często jednak używa się tego określenia dla bardzo konkretnego formatu zapisu czasu – Unix Timestamp, czyli liczby sekund, które minęły od północy w dniu 01.01.1970.
Nie będziemy teraz wchodzić w szczegóły powodów, dla których wybrano akurat tę datę. Ważne dla nas jest to, że ta liczba jest często stosowana do zapisu daty i godziny.
Timestamp w JS-ie jest jednak jeszcze innym formatem – liczbą milisekund, które minęły od północy w dniu 01.01.1970. Dlatego wykonując jakiekolwiek obliczenia na timestampie, często spotkasz się z dzieleniem przez 1000.
Innym często spotykanym zapisem daty i godziny jest format zgodny ze specyfikacją ISO 8601. Jest on znacznie bardziej czytelny dla nas, ponieważ wygląda tak: 2019-05-14T11:57:58.135Z. Mamy w nim kolejno:
- rok, miesiąc, dzień,
- literę
T oddzielającą datę od godziny,
- godziny, minuty, sekundy, milisekundy,
- oraz literę
Z oznaczającą strefę czasową (Zulu, czyli UTC).
O ile timestamp podaje zawsze czas UTC, to format ISO 8601 pozwala na podanie czasu w dowolnej strefie czasowej. Najbardziej przydatną dla nas będzie jednak strefa UTC, o czym powiemy więcej za chwilę.
Stworzenie metody mockDate
W takim razie już wiemy, że potrzebujemy zmienić działanie new Date() oraz Date.now() w taki sposób, aby zawsze zwracały tę samą wartość ustawioną przez nas. Stworzymy więc nową klasę, którą nazwiemy mockDate, dziedziczącą z klasy Date. Dodaj ją wewnątrz dodanego przed chwilą bloku describe.
describe('Component HappyHourAd with mocked Date', () => {
const mockDate = class extends Date {
constructor(...args) {
super(...args);
return this;
}
};
});
Na razie klasa mockDate jeszcze niczego nie robi, ale musieliśmy zacząć od podstaw. Ta klasa rozszerza klasę Date, a więc w tym momencie będzie działać dokładnie tak samo, jak Date. W konstruktorze użyliśmy wyrażenia super, które odwołuje się do konstruktora klasy-rodzica (w tym przypadku Date). Wywołaliśmy ten konstruktor z takimi samymi argumentami, jakie otrzymał nasz konstruktor klasy mockDate. Na końcu konstruktora zwróciliśmy obiekt this.
Jednak chwila, czy zwykle nie używaliśmy składni class nazwaKlasy extends...? Tak jest, ale podobnie jak mogliśmy definiować anonimowe funkcje, tak samo można zdefiniować anonimową klasę i przypisać ją do zmiennej lub stałej. Dzięki temu dbamy o zakres zmiennych – nasza klasa mockDate ma istnieć tylko w tym bloku describe.
Zmieńmy teraz jej sposób działania tak, aby podawała zawsze tę samą godzinę! Ma to się dziać tylko w sytuacji, kiedy nie podano żadnych argumentów. Możemy więc wykorzystać blok if/else:
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
const mockDate = class extends Date {
constructor(...args) {
if(args.length){
super(...args);
} else {
super(customDate);
}
return this;
}
};
});
Dla lepszego uporządkowania kodu datę i godzinę zapisaliśmy w stałej customDate. Wykorzystaliśmy do tego celu format ISO 8601, aby łatwo było nam modyfikować godzinę w razie potrzeby.
Działanie tego kodu jest bardzo proste – jeśli podano jakieś argumenty, to zostanie wywołany konstruktor Date (czyli super) z tymi argumentami. W przeciwnym wypadku wywołamy go z argumentem w postaci daty, którą chcemy zwracać.
W bardzo podobny sposób możemy dodać metodę now. Będzie to metoda statyczna (static), co oznacza, że nie będziemy jej wywoływać na instancji klasy mockDate, ale na tej klasie samej w sobie. Innymi słowy, będzie wywoływana jako mockDate.now().
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
const mockDate = class extends Date {
constructor(...args) {
}
static now(){
return (new Date(customDate)).getTime();
}
};
});
Podmiana klasy Date
No dobrze, mamy już klasę mockDate, która działa dokładnie tak samo, jak Date, z tym że zawsze zwraca tę samą datę i godzinę. Jednak nasz komponent nie będzie używał mockDate, tylko Date, więc po co nam ta klasa? Wykorzystamy ją do podmiany klasy Date. Moglibyśmy to zrobić np. tak:
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
const mockDate = class extends Date {
};
global.Date = mockDate;
});
Obiekt global
Jak zapewne pamiętasz, w przeglądarce istnieje obiekt window. Znajdują się w nim wszystkie metody i klasy dostępne globalnie w JS-ie. Dla przykładu, kiedy piszemy document.querySelector('.product-list') lub Math.round(), w rzeczywistości wykorzystujemy obiekty window.document i window.Math.
Nasze testy jednostkowe nie są uruchamiane w przeglądarce, tylko w NodeJS – silniku JS, który jest uruchamiany bez przeglądarki. W NodeJS nie ma obiektu window, ale zamiast niego wszystkie globalne metody i klasy znajdują się w obiekcie global.
Dlatego chcąc podmienić klasę Date, korzystamy z global.Date.
Ten sposób ma jednak dla nas poważny problem – klasa Date zostanie zmieniona na stałe. Oznacza to, że nie będziemy mogli w kolejnych testach używać prawdziwej klasy Date, która zwraca aktualną datę i godzinę. Dlatego wewnątrz testu, który za moment napiszemy, będziemy najpierw podmieniać Date na mockDate, a potem przywracać oryginalną wartość Date, którą zapiszemy w stałej trueDate.
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
const trueDate = Date;
const mockDate = class extends Date {
};
it('should show correct at 11:57:58', () => {
global.Date = mockDate;
global.Date = trueDate;
});
});
Dodanie testu
Mamy już gotową podmianę klasy Date na naszą klasę mockDate, więc możemy napisać test. Nie przejmuj się – sam test będzie bardzo prosty! Na końcu tego describe (pod afterAll) dodaj test:
it('should show correct at 11:57:58', () => {
global.Date = mockDate;
const component = shallow(<HappyHourAd {...mockProps} />);
const renderedTime = component.find(select.descr).text();
expect(renderedTime).toEqual('122');
global.Date = trueDate;
});
Sprawdzanie wielu przypadków
Pojawia się jednak teraz pytanie: czy wystarczy przetestować tylko jedną godzinę? Może nasz komponent zadziałałby nieprawidłowo dla dłuższego czasu? A może warto byłoby przetestować też wartości brzegowe, czyli 11:59:59, albo 13:00:00?
Moglibyśmy skopiować cały ten describe i podać inną godzinę. Tylko czy warto się tak powtarzać? Przecież te trzy przypadki są prawie takie same, a wiemy, że jedną z dobrych praktyk jest zasada DRY – Don't Repeat Yourself.
Możemy jednak szybko rozwiązać ten problem! Potrzebujemy w tym celu wykonać kilka kroków – pamiętaj, że na niektórych etapach ten kod nie będzie działał poprawnie. Przetestuj go dopiero po wykonaniu wszystkich kroków.
Zaczniemy od przeniesienia trueDate i mockDate poza describe:
const trueDate = Date;
const mockDate = class extends Date {
constructor(...args) {
if(args.length){
super(...args);
} else {
super(customDate);
}
return this;
}
static now(){
return (new Date(customDate)).getTime();
}
};
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
it('should show correct at 11:57:58', () => {
global.Date = mockDate;
global.Date = trueDate;
});
});
Następnie zmienimy mockDate tak, aby była funkcją, która zwraca klasę (tę samą, która teraz jest zapisana w mockDate).
const trueDate = Date;
const mockDate = () => class extends Date {
constructor(...args) {
if(args.length){
super(...args);
} else {
super(customDate);
}
return this;
}
static now(){
return (new Date(customDate)).getTime();
}
};
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
it('should show correct at 11:57:58', () => {
global.Date = mockDate();
global.Date = trueDate;
});
});
W mockDate znajduje się już funkcja, ale w niej wykorzystujemy nadal customDate – stałą, którą definiujemy dopiero wewnątrz describe. W takim razie musimy wewnątrz testu (it) przekazywać wartość daty do funkcji mockDate!
const trueDate = Date;
const mockDate = customDate => class extends Date {
constructor(...args) {
if(args.length){
super(...args);
} else {
super(customDate);
}
return this;
}
static now(){
return (new Date(customDate)).getTime();
}
};
describe('Component HappyHourAd with mocked Date', () => {
const customDate = '2019-05-14T11:57:58.135Z';
it('should show correct at 11:57:58', () => {
global.Date = mockDate(customDate);
global.Date = trueDate;
});
});
Skoro teraz wewnątrz describe wykorzystujemy customDate już tylko raz, nie ma potrzeby deklarowania tej stałej – jej wartość możemy użyć bezpośrednio w argumencie mockDate.
const trueDate = Date;
const mockDate = customDate => class extends Date {
constructor(...args) {
if(args.length){
super(...args);
} else {
super(customDate);
}
return this;
}
static now(){
return (new Date(customDate)).getTime();
}
};
describe('Component HappyHourAd with mocked Date', () => {
it('should show correct at 11:57:58', () => {
global.Date = mockDate('2019-05-14T11:57:58.135Z');
global.Date = trueDate;
});
});
Teraz nasz kod po zmianach powinien już działać – ale nie spoczywamy jeszcze na laurach! Nadal testowanie kilku przypadków wymagałoby od nas powtórzenia kilku linii kodu. Skoro nie chcemy się powtarzać, to możemy pójść krok dalej – cały test it możemy również zamknąć w funkcji! Będzie ona potrzebowała tylko dwóch argumentów – czasu i oczekiwanej wartości:
const trueDate = Date;
const mockDate = customDate => class extends Date {
};
const checkDescriptionAtTime = (time, expectedDescription) => {
it(`should show correct at ${time}`, () => {
global.Date = mockDate(`2019-05-14T${time}.135Z`);
const component = shallow(<HappyHourAd {...mockProps} />);
const renderedTime = component.find(select.descr).text();
expect(renderedTime).toEqual(expectedDescription);
global.Date = trueDate;
});
};
describe('Component HappyHourAd with mocked Date', () => {
checkDescriptionAtTime('11:57:58', '122');
checkDescriptionAtTime('11:59:59', '1');
checkDescriptionAtTime('13:00:00', 23 * 60 * 60 + '');
});
Teraz w describe potrzebujemy tylko wykonać kilkukrotnie funkcję checkDescriptionAtTime, która zawiera definicję testu, wykorzystującego funkcję mockDate do tymczasowej podmiany Date na klasę, która będzie zawsze zwracała tę samą wartość (dla pojedynczego testu).
W ostatnim z testów – tym dla godziny 13:00 – pozwoliliśmy sobie na nieco lenistwa. Zamiast podawać tekst, który powinien wyświetlić się w naszym komponencie, wykonaliśmy działanie: liczbę godzin (23 godziny od 13:00 do 12:00) przemnożyliśmy przez 60 (liczba minut w godzinie) i ponownie przez 60 (liczba sekund w minucie). Na końcu dodaliśmy pusty ciąg znaków, aby skonwertować liczbę na tekst.
Nazwaliśmy to lenistwem, ale taki zapis ma bardzo ważny sens – sprawi, że inny developer z zespołu (albo my sami za jakiś czas) będzie miał dużo mniej trudności ze zrozumieniem tego fragmentu kodu!
Rozwój komponentu HappyHourAd
Po napisaniu testu jesteśmy w fazie Red i możemy przejść do implementacji tej funkcjonalności. Możesz spróbować zmierzyć się z tym samodzielnie, ale biorąc pod uwagę dość skomplikowany test, który musieliśmy napisać, zostawiamy poniżej gotowe rozwiązanie tej funkcjonalności.
Najpierw jednak wyjaśnimy kilka zagadnień, które przydadzą Ci się niezależnie od tego, czy zdecydujesz się spróbować samodzielnie, czy tylko przeanalizować i zrozumieć poniższe rozwiązanie.
Obliczenie ilości sekund do najbliższego południa może wydawać Ci się bardzo żmudnym zadaniem, ale wcale nie będzie takie trudne. Pewnym wyzwaniem może być jednak poradzenie sobie ze strefami czasowymi. Należy bardzo uważać na wykorzystywane metody klasy Date, aby nie pomylić czasu UTC z czasem w lokalnej strefie czasowej użytkownika.
Całe szczęście, klasa Date ma wbudowane metody, takie jak Date.UTC czy getUTCHours, które pozwalają nam operować na czasie UTC. Obiekt Date zajmie się też konwersją czasu lokalnego na UTC. Dlatego najwygodniej będzie nam stworzyć dwie stałe zawierające instancje Date. Jedna z nich będzie wskazywała aktualny czas, a druga – najbliższą godzinę 12:00.
Pokaż rozwiązanie
Ukryj rozwiązanie
W naszym komponencie HappyHourAd dodamy nową metodę – getCountdownTime:
getCountdownTime(){
const currentTime = new Date();
const nextNoon = new Date(Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate(), 12, 0, 0, 0));
if(currentTime.getUTCHours() >= 12){
nextNoon.setUTCDate(currentTime.getUTCDate()+1);
}
return Math.round((nextNoon.getTime() - currentTime.getTime())/1000);
}
W metodzie render możesz wykorzystać wartość zwracaną przez tę funkcję, np. tak:
<div className='promoDescription'>{this.getCountdownTime()}</div>
Do przeanalizowania metody getCountdownTime może Ci się przydać dokumentacja obiektu Date.
Po zaimplementowaniu tej funkcjonalności sprawdź, czy testy zaczęły przechodzić. Jeśli tak, to wszystko poszło dobrze (faza Green). Na końcu możesz ew. zrefaktorować kod, jeśli uważasz, że da się go poprawić (faza Refactor).
Test 5: czy odliczanie co sekundę zmniejsza wyświetlaną liczbę
Czas na sprawdzenie kolejnego wymagania – licznik powinien co sekundę zmniejszać wyświetlaną wartość.
Zwróć uwagę, że na tym etapie nie musimy już sprawdzać, czy początkowo wyświetlana jest poprawna wartość. To jest sprawdzane przez wcześniejsze testy, więc teraz możemy się skupić tylko na tym, czy wyświetlany czas poprawie się zmienia.
Z napisaniem tego testu już sobie poradzisz samodzielnie, prawda? Przy poprzednim teście nauczyliśmy się, jak możemy zasymulować konkretną godzinę. Natomiast do poczekania, aż wyświetli się nowa wartość, moglibyśmy użyć setTimeout, aby kolejne sprawdzenie wyświetlanej wartości wykonało się np. po dwóch sekundach.
Problem w tym, że taki test rzeczywiście będzie czekał dwie sekundy. Jeśli będziemy chcieli sprawdzić trzy przypadki – to będzie 6 sekund. Do tego będziemy za chwilę sprawdzać, czy po doliczeniu do zera wyświetli się informacja o promocji – to będzie kolejnych kilka sekund... Łatwo sobie wyobrazić, że niedługo nasz test wykonywałby się długimi minutami!
Na szczęście Jest oferuje nam proste wyjście. Wystarczy użyć metody jest.useFakeTimers(). Pozwala ona symulować przebieg czasu, m.in. w setTimeout oraz setInterval. Komponentowi będzie się wydawało, że faktycznie minęły dwie sekundy, podczas gdy w rzeczywistości minie raptem kilka milisekund.
Musimy też pamiętać, aby zmienić godzinę zwracaną przez naszą klasę mockDate – inaczej mimo wykonania funkcji zawartej w setTimeout, nasz komponent będzie myślał, że jest nadal ta sama godzina.
Tworzymy nowy test w oparciu o poprzedni
Zacznij od skopiowania funkcji checkDescriptionAtTime i wklejenia jej ponownie, tym razem pod nazwą checkDescriptionAfterTime (After zamiast At). Dodaj w nim drugi argument (tzn. pomiędzy pierwszym a drugim) o nazwie delaySeconds. Dla czytelności możesz też zmienić opis testu (pierwszy argument w it). Pierwsze dwie linie tej nowej funkcji powinny wyglądać teraz tak:
const checkDescriptionAfterTime = (time, delaySeconds, expectedDescription) => {
it(`should show correct value ${delaySeconds} seconds after ${time}`, () => {
Pod tymi liniami (czyli pomiędzy liniami z it i z global.Date) dodaj linię jest.useFakeTimers();, a na samym końcu testu (pod ostatnią linią z global.Date) dodaj jest.useRealTimers();. Dzięki temu bieg czasu w JS-ie wykonywanym pomiędzy tymi liniami będzie kontrolowany przez Jesta.
Następnie pomiędzy linią montującą komponent (shallow) a linią odczytującą tekst z komunikatu (const renderedTime) wstaw poniższy kod:
const newTime = new Date();
newTime.setSeconds(newTime.getSeconds() + delaySeconds);
global.Date = mockDate(newTime.getTime());
jest.advanceTimersByTime(delaySeconds * 1000);
Pierwsze trzy linie tego fragmentu są odpowiedzialne za ustawienie nowej metody Date. Najpierw pobieramy "aktualną" datę i godzinę – pamiętamy jednak, że wcześniej podmieniliśmy Date na klasę, która zawsze zwróci nam tę samą godzinę. W drugiej linii modyfikujemy tę godzinę, dodając do niej wartość argumentu delaySeconds, a następnie podmieniamy Date na nowy mock ze zmienioną godziną. Dzięki temu od teraz Date będzie zwracał czas późniejszy o tyle sekund, ile podaliśmy w argumencie delaySeconds.
Ostatnia linia powyższego fragmentu odpowiedzialna jest za kontrolę biegu czasu w JS-ie. Nasz komponent co sekundę sprawdza aktualny czas (za pomocą Date) i na jego podstawie wyświetla odpowiednią wartość. Oznacza to, że co sekundę komponent na nowo się renderuje. Za pomocą metody advanceTimersByTime przyspieszamy bieg czasu właśnie po to, aby wykonało się kolejne odświeżenie komponentu.
To może być nieco mylące, że kontrolujemy dwa rodzaje czasu, więc podsumujmy jeszcze raz:
- Klasa
Date odpowiedzialna jest za sprawdzenie aktualnego czasu (lub zdefiniowanego przez nas, udającego aktualny czas).
- Timery, kontrolowane przez
useFakeTimers i advanceTimersByTime, wpływają na to, kiedy zostanie wykonana funkcja przekazana do setTimeout lub setInterval.
Te dwa rodzaje czasu działają niezależnie od siebie i dlatego potrzebujemy kontrolować je osobno. Możesz to sobie wyobrazić jako zegarek i stoper – dwa osobne urządzenia, z których pierwsze mówi nam, która jest godzina, a drugi przypomina o tym, żeby co sekundę spojrzeć na zegarek.
Musimy pamiętać o posprzątaniu po sobie – dlatego na końcu testu użyliśmy metody useRealTimers, aby wyłączyć timery symulowane przez Jesta i przywrócić JS do normalnego trybu działania.
Nasza nowa funkcja checkDescriptionAfterTime jest już gotowa, możemy ją teraz wykorzystać. Skopiuj poprzedni blok describe razem z zawartością. Zmień treść opisu, zamień nazwę wywoływanej funkcji z checkDescriptionAtTime na checkDescriptionAfterTime, i w każdym jej wywołaniu pomiędzy pierwszym a drugim argumentem wstaw opóźnienie. Pamiętaj, aby odpowiednio dostosować oczekiwane wartości!
My wybraliśmy poniższe wartości, ale śmiało możesz użyć innych:
describe('Component HappyHourAd with mocked Date and delay', () => {
checkDescriptionAfterTime('11:57:58', 2, '120');
checkDescriptionAfterTime('11:59:58', 1, '1');
checkDescriptionAfterTime('13:00:00', 60 * 60, 22 * 60 * 60 + '');
});
Implementacja testowanej funkcjonalności
Kod odpowiedzialny za wyświetlanie poprawnego czasu jest wykonywany w metodzie render (która wywołuje metodę getCountdownTime), więc do zaimplementowania tej funkcjonalności potrzebujemy tylko dodać odświeżanie widoku komponentu co sekundę. W tym celu musimy dodać fragment kodu wykonywany w momencie stworzenia instancji komponentu, który zainicjuje odświeżanie widoku. Najprościej będzie w komponencie HappyHourAd dodać konstruktor:
constructor(){
super();
}
W miejscu komentarza musisz wpisać kod, który co sekundę wykona this.forceUpdate(). Możesz do tego wykorzystać setInterval, ale pamiętaj, aby użyć w nim funkcji strzałkowej, ponieważ funkcja anonimowa (ze słowem function) zmieniłaby znaczenie this i nasz kod nie zadziałałby poprawnie.
Po uzupełnieniu kodu sprawdź, czy testy przechodzą (Green), a następnie zastanów się, czy widzisz potrzebę poprawienia czytelności kodu (Refector).
Ten przypadek jest już znacznie prostszy. Pomiędzy godzinami 12:00:00 i 12:59:59 (włącznie z nimi), zamiast sekund w elemencie .promoDescription powinien pojawiać się tekst z propsa promoDescription. Twoim zadaniem jest – jak zawsze w TDD – najpierw napisanie testów, a potem poprawne zaimplementowanie tej funkcjonalności.
Do tego celu możesz skopiować describe z testu 4, czyli ten, w którym wykorzystujemy checkDescriptionAtTime (At, nie After). Ta funkcja tworzy dla nas test sprawdzający, czy tekst w komunikacie jest poprawna o określonej godzinie. Dokładnie tego potrzebujemy! Pamiętaj, że oczekiwana wartość komunikatu jest zapisana w mockProps.promoDescription.
Uwaga! Nie zapomnij o sprawdzeniu wartości brzegowych – 12:00:00 i 12:59:59.
Przy implementacji tej funkcjonalności najlepiej będzie w metodzie render zapisać do stałej wartość zwracaną przez this.getCountdownTime(). Jeśli ta liczba jest większa niż równowartość 23 godzin, to ma zostać wyświetlona informacja o promocji (przekazywana w propsie). W przeciwnym wypadku powinna zostać wyświetlona wartość tej stałej, co da taki sam efekt, jak do tej pory.
Czas na ostatni przypadek, z którym również poradzisz sobie bez problemu. Tym razem skopiujemy describe z testu 5, w którym wykorzystywaliśmy funkcję checkDescriptionAfterTime (After, nie At). Dzięki niej sprawdzisz, czy komponent zachowa się poprawnie, kiedy test rozpocznie się przed godziną 12:00, a treść komunikatu sprawdzimy po godzinie 12:00. W takiej sytuacji komponent będzie początkowo wyświetlał liczbę sekund (to już testowaliśmy wcześniej), a kiedy minie 12:00, powinien zacząć wyświetlać informację o promocji.
W związku ze sposobem, w jaki zaimplementowaliśmy nasz komponent, ten test powinien od razu być zaliczony (Green).
Podłączamy komponent do aplikacji
Udało nam się skończyć implementację tego komponentu w podejściu TDD! Czas na jego praktyczne wykorzystanie!
Zanim jednak to zrobimy, dodamy jeszcze style tego komponentu. Do pliku HappyHourAd.scss wstaw następujący kod:
.component {
position: relative;
text-align: center;
color: #fff;
}
.title, .promoDescription {
position: absolute;
width: 100%;
}
.title {
bottom: 40px;
}
.promoDescription {
bottom: 10px;
}
Przy importowaniu staraj się zachować spójność z resztą aplikacji, a więc załaduj style do obiektu styles:
import styles from './HappyHourAd.scss';
I to właśnie z tego obiektu korzystaj w render. Dla przykładu wrapper (czyli div, w którym znajdują się tytuł i komentarz) powinien otrzymać props:
className={styles.component}
Analogicznie zastosuj klasy title i promoDescription.
Za chwilę zaimportujemy ten komponent do Hero (znajdziesz go w katalogu components/layouts), ale nadal trzymamy się TDD, więc najpierw dodamy test do Hero.test.js. Sprawdzimy tylko, czy komponent HappyHourAd w ogóle w nim istnieje. Wzorując się na innych testach z tego pliku, nowy test może wyglądać tak:
it('should render HappyHourAd', () => {
const expectedTitle = 'Lorem ipsum';
const expectedImage = 'image.jpg';
const component = shallow(<Hero titleText={expectedTitle} imageSrc={expectedImage} />);
expect(component.find('HappyHourAd').length).toEqual(1);
});
Następnie w komponencie Hero, pod obrazkiem dodaj div z className={styles.happyHour}, a w nim umieść komponent HappyHourAd. Aby ten komponent był widoczny, w pliku Hero.scss dodaj style:
.happyHour {
z-index: 999;
position: absolute;
bottom: 20px;
left: 0;
right: 0;
}
Efektem powinien być taki widok na głównej stronie:
Podsumowanie
Jak widzisz, TDD może być na początku trochę męczące. W końcu, żeby napisać cokolwiek w aplikacji, najpierw wszystko musimy opakować testami. Nie sposób jednak nie docenić, jak pomocny taki sposób staje się podczas implementacji.
Wszystkie błędy pokazywane są na bieżąco, możemy dzięki temu bardzo szybko je wyłapać. W tym podejściu pisanie testów nie jest przykrym obowiązkiem na samym końcu projektu, ale elementem planowania nowych funkcjonalności.
Dzięki testowaniu małych fragmentów łatwiej jest nam sobie z nimi poradzić i nie musimy zastanawiać się, co właściwie mieliśmy przetestować. A do tego, od samego początku mamy swego rodzaju dokumentację, która mówi nam, jak mniej więcej dana funkcjonalność powinna działać. Daje nam to wygodne ramy, których możemy się trzymać przy implementacji.
Co więcej, możemy być spokojni, że gdyby jakiekolwiek przyszłe zmiany w kodzie miały zepsuć dotychczasową funkcjonalność, zostaniemy o tym od razu poinformowani.